package com.hzw.saas.web.shell.service;

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.date.SystemClock;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IORuntimeException;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.ssh.JschUtil;
import cn.hutool.extra.ssh.Sftp;
import cn.hutool.json.JSONUtil;
import com.hzw.saas.api.storage.bo.InxChunkBO;
import com.hzw.saas.common.config.exception.DefaultSaasException;
import com.hzw.saas.common.config.util.AssertUtil;
import com.hzw.saas.common.config.util.RedisUtil;
import com.hzw.saas.common.util.PathUtils;
import com.hzw.saas.web.shell.service.bo.SftpFileBO;
import com.hzw.saas.web.shell.service.bo.SshConnBO;
import com.hzw.saas.web.shell.util.JschEnhanceUtil;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpATTRS;
import com.jcraft.jsch.SftpException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;

/**
 * @author zzl
 * @since 06/04/2021
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class SftpFileServiceImpl implements ISftpFileService {

    private static final String LOG_PREFIX = "[SFTP]";

    private static final int NOT_FOUND = 0;
    private static final int IS_FILE = 2;
    private static final int IS_FOLDER = 1;
    private static final long CHUNK_INDEX_CACHE_EXPIRE_TIME = 60 * 60 * 24 * 2;
    private static final String CHUNK_INDEX_CACHE_KEY_GROUP = "storage:sftp:";


    @Override
    public String getHome(SshConnBO sshConn) {
        Sftp sftp = null;
        try {
            sftp = JschEnhanceUtil.getSftp(sshConn.getHost(), sshConn.getPort(), sshConn.getUsername(), sshConn.getPassword(), sshConn.getSid());
            return sftp.home();
        } finally {
            JschEnhanceUtil.closeChannelInSftp(sftp);
        }
    }

    @Override
    public void close(SshConnBO sshConn) {
        JschEnhanceUtil.closeSession(sshConn.getSid());
    }

    @Override
    public SftpFileBO getFileInfo(SshConnBO sshConn, String path) {
        Sftp sftp = null;
        try {
            sftp = JschEnhanceUtil.getSftp(sshConn.getHost(), sshConn.getPort(), sshConn.getUsername(), sshConn.getPassword(), sshConn.getSid());
            SftpATTRS lstat = sftp.getClient().lstat(path);
            SftpFileBO sftpFileBO = new SftpFileBO();
            sftpFileBO.setName(FileUtil.getName(path));
            sftpFileBO.setLength(lstat.getSize());
            sftpFileBO.setType(lstat.isDir() ? IS_FOLDER : IS_FILE);
            sftpFileBO.setCreateDate(new Date(lstat.getMTime() * 1000L));
            return sftpFileBO;
        } catch (SftpException e) {
            return null;
        } finally {
            JschEnhanceUtil.closeChannelInSftp(sftp);
        }
    }

    @Override
    public void transferChunkOnSftp(SshConnBO sshConn, String tarDirPath, InxChunkBO chunkInfo, File chunkFile) {
        // 检查参数合法性
        AssertUtil.assertThrow("待保存分片文件未找到: " + JSONUtil.toJsonStr(chunkInfo), !FileUtil.exist(chunkFile));
        BufferedInputStream inputStream = FileUtil.getInputStream(chunkFile);
        this.transferChunkOnSftp(sshConn, tarDirPath, chunkInfo, inputStream);
    }

    @Override
    public void transferChunkOnSftp(SshConnBO sshConn, String tarDirPath, InxChunkBO chunkInfo, InputStream ins) {
        tarDirPath = PathUtils.normalizeAbsolutePath(tarDirPath, false);
        log.debug(LOG_PREFIX.concat("上传分片({}:{}/{} {})..."), sshConn.getHost(), tarDirPath, chunkInfo.getFilename(), chunkInfo.getChunkNumber());
        // 检查参数
        try {
            this.checkParams(sshConn, tarDirPath, chunkInfo, ins.available());
        } catch (IOException e) {
            throw DefaultSaasException.build(LOG_PREFIX.concat("获取上传的文件字节数失败"), e);
        }
        // 检查目标目录
        int tarType = this.getPathType(sshConn, tarDirPath);
        // 检查是否存在与目标目录同名的文件
        if (Objects.equals(tarType, IS_FILE)) {
            throw DefaultSaasException.build(LOG_PREFIX.concat("已存在与上传目录同名的文件"));
        }
        // 创建目标目录（不存在时）
        if (Objects.equals(tarType, NOT_FOUND)) {
            this.mkdir(sshConn, tarDirPath);
        }
        // 检查分片是否连续传输
        this.assertChunkIndexConsequent(sshConn, tarDirPath, chunkInfo);

        // 传输分片文件
        String filePath = PathUtils.connectPath(tarDirPath, chunkInfo.getFilename());
        filePath = PathUtils.normalizeAbsolutePath(filePath, false);
        ChannelSftp sftpChannel = null;
        try {
            sftpChannel = JschEnhanceUtil.getChannelSftp(sshConn.getHost(), sshConn.getPort(), sshConn.getUsername(), sshConn.getPassword(), sshConn.getSid());
            if (chunkInfo.onFirst()) {
                sftpChannel.put(ins, filePath, ChannelSftp.OVERWRITE);
            } else {
                sftpChannel.put(ins, filePath, ChannelSftp.APPEND);
            }
            log.debug(LOG_PREFIX.concat("上传分片({}:{}/{} {})完成."), sshConn.getHost(), tarDirPath, chunkInfo.getFilename(), chunkInfo.getChunkNumber());

            // 文件传输完成，检查完整性
            if (chunkInfo.onLast()) {
                SftpATTRS lstat = sftpChannel.lstat(filePath);
                if (!Objects.equals(lstat.getSize(), chunkInfo.getTotalSize())) {
                    sftpChannel.rm(filePath);
                    this.rmCachedChunkIndex(chunkInfo.getFileId());
                    throw new Exception(String.format(LOG_PREFIX.concat("文件传输完成，文件大小异常，expected: %s, actual: %s"), chunkInfo.getTotalSize(), lstat.getSize()));
                }
                log.debug(LOG_PREFIX.concat("文件上传({}:{}/{})完成."), sshConn.getHost(), tarDirPath, chunkInfo.getFilename());
            }

            // 保存分片索引缓存
            this.cacheChunkIndex(chunkInfo.getFileId(), chunkInfo.getChunkNumber());
        } catch (IOException e) {
            throw new IORuntimeException(e);
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            IoUtil.close(ins);
            JschUtil.close(sftpChannel);
        }
    }

    @Override
    public boolean exist(SshConnBO sshConn, String path) {
        SftpFileBO fileInfo = this.getFileInfo(sshConn, path);
        return Objects.nonNull(fileInfo);
    }

    @Override
    public List<SftpFileBO> list(SshConnBO sshConn, String supPath) {
        List<SftpFileBO> files = new ArrayList<>();
        List<SftpFileBO> folders = new ArrayList<>();
        Sftp sftp = null;
        try {
            sftp = JschEnhanceUtil.getSftp(sshConn.getHost(), sshConn.getPort(), sshConn.getUsername(), sshConn.getPassword(), sshConn.getSid());
            List<ChannelSftp.LsEntry> lsEntries = sftp.lsEntries(supPath);
            for (ChannelSftp.LsEntry lsEntry : lsEntries) {
                SftpFileBO fileBO = new SftpFileBO();
                fileBO.setName(lsEntry.getFilename());
                fileBO.setLength(lsEntry.getAttrs().getSize());
                fileBO.setCreateDate(new Date(lsEntry.getAttrs().getMTime() * 1000L));
                if (lsEntry.getAttrs().isDir()) {
                    fileBO.setType(IS_FOLDER);
                    folders.add(fileBO);
                } else {
                    fileBO.setType(IS_FILE);
                    files.add(fileBO);
                }
            }
            folders.addAll(files);
            return folders;
        } finally {
            JschEnhanceUtil.closeChannelInSftp(sftp);
        }
    }

    @Override
    public void mkdir(SshConnBO sshConn, String path) {
        path = PathUtils.normalizeAbsolutePath(path, false);
        // 检查已存在同名目录
        SftpFileBO fileInfo = this.getFileInfo(sshConn, path);
        AssertUtil.assertThrow("已存在同名文件或目录", Objects.nonNull(fileInfo));
        String execFormat = "mkdir -p \"%s\"";
        String exec = String.format(execFormat, path);
        log.debug(LOG_PREFIX.concat("创建目录：{}"), path);
        this.execWithNoResult(sshConn, exec);
    }

    @Override
    public void rename(SshConnBO sshConn, String srcPath, String newName) {
        String dirPath = FileUtil.getParent(srcPath, 1);
        String newPath = PathUtils.connectPath(dirPath, newName);
        log.debug(LOG_PREFIX.concat("重命名文件/目录：{} -> {}"), srcPath, newPath);
        this.mv(sshConn, srcPath, newPath);
    }

    @Override
    public void mv(SshConnBO sshConn, String oldPath, String newPath) {
        oldPath = PathUtils.normalizeAbsolutePath(oldPath, false);
        newPath = PathUtils.normalizeAbsolutePath(newPath, false);
        // 源与目标相同
        if (Objects.equals(oldPath, newPath)) return;
        this.checkBeforeMvAndCp(sshConn, oldPath, newPath);
        String execFormat = "mv -f \"%s\" \"%s\"";
        String exec = String.format(execFormat, oldPath, newPath);
        log.debug(LOG_PREFIX.concat("移动文件/目录: {} -> {}"), oldPath, newPath);
        this.execWithNoResult(sshConn, exec);
    }

    @Override
    public void mv(SshConnBO sshConn, List<String> oldPaths, String newDirPath) {
        if (CollectionUtil.isEmpty(oldPaths))
            return;
        newDirPath = PathUtils.normalizeAbsolutePath(newDirPath, false);
        for (String oldPath : oldPaths) {
            String name = FileUtil.getName(oldPath);
            String newPath = PathUtils.connectPath(newDirPath, name);
            this.mv(sshConn, oldPath, newPath);
        }
    }

    @Override
    public void cp(SshConnBO sshConn, String oldPath, String newPath) {
        oldPath = PathUtils.normalizeAbsolutePath(oldPath, false);
        newPath = PathUtils.normalizeAbsolutePath(newPath, false);
        // 源与目标相同
        if (Objects.equals(oldPath, newPath)) return;
        this.checkBeforeMvAndCp(sshConn, oldPath, newPath);
        String execFormat = "cp -rf \"%s\" \"%s\"";
        String exec = String.format(execFormat, oldPath, newPath);
        log.debug(LOG_PREFIX.concat("复制文件/目录: {} -> {}"), oldPath, newPath);
        this.execWithNoResult(sshConn, exec);
    }

    @Override
    public void cp(SshConnBO sshConn, List<String> oldPaths, String newDirPath) {
        if (CollectionUtil.isEmpty(oldPaths))
            return;
        newDirPath = PathUtils.normalizeAbsolutePath(newDirPath, false);
        for (String oldPath : oldPaths) {
            String name = FileUtil.getName(oldPath);
            String newPath = PathUtils.connectPath(newDirPath, name);
            this.cp(sshConn, oldPath, newPath);
        }
    }

    @Override
    public void rm(SshConnBO sshConn, String path) {
        path = PathUtils.normalizeAbsolutePath(path, false);
        String execFormat = "rm -r \"%s\"";
        String exec = String.format(execFormat, path);
        log.debug(LOG_PREFIX.concat("删除文件/目录: {}"), path);
        this.execWithNoResult(sshConn, exec);
    }

    @Override
    public void download(SshConnBO sshConn, String srcPath, OutputStream ous) {
        srcPath = PathUtils.normalizeAbsolutePath(srcPath, false);
        ChannelSftp sftpChannel = null;
        try {
            log.debug(LOG_PREFIX.concat("开始下载文件({})..."), srcPath);
            long start = SystemClock.now();
            sftpChannel = JschEnhanceUtil.getChannelSftp(sshConn.getHost(), sshConn.getPort(), sshConn.getUsername(), sshConn.getPassword(), sshConn.getSid());
            sftpChannel.get(srcPath, ous);
            long cost = SystemClock.now() - start;
            log.debug(LOG_PREFIX.concat("下载文件完成({}), 耗时： {}"), srcPath, cost);
        } catch (Exception e) {
            throw DefaultSaasException.build("下载文件异常", e);
        } finally {
            JschUtil.close(sftpChannel);
        }
    }

    // ================================================ private method ================================================ //

    /**
     * 检查参数合法性
     */
    private void checkParams(SshConnBO sshConn, String tarPath, InxChunkBO chunkInfo, long fileLength) {
        // 检查非最后一个分片的大小
        if (!Objects.equals(chunkInfo.getChunkNumber(), chunkInfo.getTotalChunks())) {
            AssertUtil.assertThrow(String.format("分片大小异常，expected: %s, actual: %s", chunkInfo.getChunkSize(), fileLength),
                !Objects.equals(chunkInfo.getChunkSize(), fileLength));
        }
        // 检查最后一个分片的大小
        else {
            long expectedSize = chunkInfo.getTotalSize() - (chunkInfo.getTotalChunks() - 1) * chunkInfo.getChunkSize();
            AssertUtil.assertThrow(String.format("结尾分片大小异常，expected: %s, actual: %s", expectedSize, fileLength),
                !Objects.equals(expectedSize, fileLength));
        }
        chunkInfo.setCurrentChunkSize(fileLength);
    }

    /**
     * 检查分片的连续性
     * <p>
     * 1. 如果是第一个分片，跳过下述检查
     * 2. 检查缓存中是否为上一分片index，是则正常传本次分片，否则抛出异常：分片未连续传递
     * 3. 如果缓存无法找到分片提交历史，则检查远程目录中该文件大小是否为本次分片之前分片大小总数
     * 4. 分片传递完成在缓存记录该fileId对应的分片index
     */
    private void assertChunkIndexConsequent(SshConnBO sshConnBO, String tarPath, InxChunkBO chunkInfo) {
        // 1. 如果是第一个分片，跳过下述检查
        if (chunkInfo.onFirst())
            return;
        // 2. 检查缓存中是否为上一分片index，是则正常传本次分片，否则抛出异常：分片未连续传递
        int previousChunkIndex = getCachedChunkIndex(chunkInfo.getFileId());
        if (!Objects.equals(previousChunkIndex, -1)) {
            AssertUtil.assertThrow(String.format("分片未连续传输，已上传分片：%s，当前分片：%s", previousChunkIndex, chunkInfo.getChunkNumber()),
                !Objects.equals(previousChunkIndex + 1, chunkInfo.getChunkNumber()));
        }
        // 3. 如果缓存无法找到分片提交历史，则检查远程目录中该文件大小是否为本次分片之前分片大小总数
        else {
            SftpFileBO fileInfo = this.getFileInfo(sshConnBO, PathUtils.connectPath(tarPath, chunkInfo.getFilename()));
            AssertUtil.assertThrow("分片传输异常，无法找到已上传的文件分片数据", Objects.isNull(fileInfo));
            long fileSize = fileInfo.getLength();
            long expectedSize = (chunkInfo.getChunkNumber() - 1) * chunkInfo.getChunkSize();
            AssertUtil.assertThrow("分片未连续传输", !Objects.equals(fileSize, expectedSize));
        }
    }

    private void checkBeforeMvAndCp(SshConnBO sshConn, String oldPath, String newPath) {
        // 检查原文件是否存在
        int oldType = this.getPathType(sshConn, oldPath);
        AssertUtil.assertThrow("原文件或目录不存在", Objects.equals(oldType, NOT_FOUND));
//        int newType = this.getPathType(sshConn, newPath);
//        AssertUtil.assertThrow("目标路径存在同名目录", Objects.equals(newType, IS_FOLDER));
        // 检查是否为目录 -> 文件
//        AssertUtil.assertThrow("目标路径存在同名文件", Objects.equals(oldType, IS_FOLDER) && Objects.equals(newType, IS_FILE));
    }

    /**
     * 缓存已上传的分片索引信息
     */
    private void cacheChunkIndex(String fileId, Integer chunkNumber) {
        RedisUtil.set(CHUNK_INDEX_CACHE_KEY_GROUP + fileId, chunkNumber, CHUNK_INDEX_CACHE_EXPIRE_TIME);
    }

    /**
     * 移除分片索引信息，一般用于文件上传完成
     */
    private void rmCachedChunkIndex(String fileId) {
        RedisUtil.del(CHUNK_INDEX_CACHE_KEY_GROUP + fileId);
    }

    /**
     * 获取已上传的分片索引
     */
    private int getCachedChunkIndex(String fileId) {
        Integer index = RedisUtil.<Integer>get(CHUNK_INDEX_CACHE_KEY_GROUP + fileId);
        return Objects.isNull(index) ? -1 : index;
    }


    /**
     * @param sshConn
     * @param exec
     */
    private void execWithNoResult(SshConnBO sshConn, String exec) {
        Session session = JschEnhanceUtil.getSession(sshConn.getHost(), sshConn.getPort(), sshConn.getUsername(), sshConn.getPassword(), sshConn.getSid());
        try (ByteArrayOutputStream ous = new ByteArrayOutputStream()) {
            String result = JschEnhanceUtil.exec(session, exec, StandardCharsets.UTF_8, ous);
            String error = ous.toString(StandardCharsets.UTF_8.name());
            if (StrUtil.isNotBlank(error))
                throw DefaultSaasException.build("ssh shell异常: " + error);
            if (StrUtil.isNotBlank(result))
                throw DefaultSaasException.build("ssh shell异常: " + result);
        } catch (IOException e) {
            log.error("execWithNoResult IO error", e);
            throw DefaultSaasException.build("IO异常：".concat(e.getMessage()));
        }
    }

    /**
     * 检查路径文件类型
     *
     * @return 0不存在, 1目录，2文件
     */
    private int getPathType(SshConnBO sshConnBO, String path) {
        SftpFileBO fileInfo = this.getFileInfo(sshConnBO, path);
        return Objects.isNull(fileInfo) ? NOT_FOUND : fileInfo.getType();
    }


}
